Skip to content

[6.x] Modernize garnish#19129

Draft
brianjhanson wants to merge 13 commits into
6.xfrom
feature/modernize-garnish
Draft

[6.x] Modernize garnish#19129
brianjhanson wants to merge 13 commits into
6.xfrom
feature/modernize-garnish

Conversation

@brianjhanson

@brianjhanson brianjhanson commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Rumors of my death are greatly exaggerated

I've taken a few runs at avoiding this, but nothing's felt quite right. So this is how I learned to stop worrying and love Garnish.

At the start of this endeavor it felt like a gargantuan task to bring Garnish into the modern age. That's all kind of changed now that we have agents that can do large swaths of the grunt work.

This PR represents a partial PoC but more likely the start of bringing Garnish into the modern age. Converting it to typescript, removing jQuery and adding better documentation via a Storybook.

While I don't think Garnish is perfect, it is pretty good, and has been consistently improved over time. I don't want to discard all those learnings from the start, but by splitting and modernizing Garnish we gain some flexibility in replacing the inner workings.

Still very much a work in progress, just opening to get the idea out there. I'm not sure I want this to live in a separate package but doing it this way made it easier to work through without messing up too much other stuff.

brianjhanson and others added 13 commits June 17, 2026 16:04
…kage

Introduce packages/craftcms-garnish: a modern, tree-shakeable TypeScript
rewrite of the legacy jQuery-based Garnish UI library, alongside an opt-in
compatibility layer so existing consumers keep working unchanged.

- garnish-core: native ES-class Base, three event systems (instance
  EventEmitter, class-level bus, namespaced DOM listener registry), custom
  events (activate/textchange/resize) without $.event.special,
  UiLayerManager/EscManager, focusable matcher, and the full utility surface.
  Zero jQuery in the modern entry (dist/index.js has no jQuery references).
- Modal: vertical-slice PoC component (Velocity -> Web Animations API).
- compat: opt-in ./compat entry restoring Garnish.Base.extend(), this.base(),
  window.Garnish, and jQuery-collection args; jQuery is an optional peer
  dependency of this entry only.
- Tooling: tsdown ESM build, Vitest (happy-dom) with 121 passing tests, a Vite
  playground (npm run dev), TSDoc on public APIs, README + docs/.

Draggable/resizable Modal (BaseDrag/DragMove) is deferred; see
docs/00-migration-plan.md for the full migration plan and effort estimate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the legacy jQuery drag base into the modern package, jQuery-free, and
use it to complete Modal's deferred draggable/resizable support.

- src/drag/base-drag.ts: BaseDrag extends Base, modernized to Pointer Events
  (dropping the legacy mouse+touch shim), with a native requestAnimationFrame
  auto-scroll loop, a WeakMap drag registry, multi-touch pointer-id guarding,
  and the full subclass contract (addItems/removeItems/startDragging/drag/
  stopDragging + onDragStart/onDrag/onDragStop hooks; beforeDragStart/dragStart/
  drag/dragStop events).
- src/drag-move.ts: replace the throwing PoC stub with the real DragMove.
- src/utils/scroll.ts: shared axis-aware getScrollParent (animation.ts now
  consumes it); add getOuterWidth/Height reserved for the future Drag port.
- src/modal.ts: remove the draggable/resizable throw blocks; draggable uses
  DragMove (container or dragHandleSelector), resizable uses a BaseDrag-driven
  resize handle with RTL-aware math; both torn down in destroy().
- Playground: draggable/header-handle/resizable Modal demos, standalone
  DragMove/BaseDrag boxes (incl. axis-locked), and an auto-scroll demo.
- Docs: BaseDrag/DragMove API reference, updated Modal status, design +
  impl notes (docs/07, docs/08).

162 tests pass; the modern entry stays jQuery-free. Live drag/auto-scroll/
resize and touch behavior are validated via the playground (not unit-testable
in happy-dom). Drag/DragDrop/DragSort remain the next drag-cluster modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port the next two drag-cluster modules onto the modern BaseDrag.

- src/drag/drag.ts: Drag extends BaseDrag — native helper-clone creation
  (cloneNode(true), border-box sizing, input-name blanking), cursor lag-follow
  positioning, and Web Animations API return-to-source / fade-out of helpers
  (mirrors Modal._fade: tracked, cancelable, prefers-reduced-motion gated).
  Preserves the legacy startDragging hook ordering (onBeforeDragStart before
  helpers are built).
- src/drag/drag-drop.ts: DragDrop extends Drag — drop-target resolution with
  native rect/hitTest hit detection and onDropTargetChange (fires on change
  only); $activeDropTarget is a raw HTMLElement.
- src/utils/misc.ts: promote shared isPlainObject (base-drag.ts now imports it).
- src/index.ts: Drag/DragDrop named exports + on the Garnish namespace.
- Playground: "Drag with helpers (clones + return-to-source)" and
  "DragDrop — drop targets & hit detection" demos.
- Docs: Drag/DragDrop API reference + status (only DragSort now pending in the
  drag cluster); design (09) + impl notes (10).

215 tests pass; modern entry stays jQuery-free. Live helper trailing, return
animation, and drop hover are validated via the playground (not unit-testable
in happy-dom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DragMove.onDrag wrote the cursor's page coordinates straight into
style.left/top, which only works when the element's containing block is at
the page origin (e.g. a <body>-relative Modal). For an absolutely-positioned
element nested in a positioned ancestor, left/top are resolved against that
ancestor's padding box, so the element jumped by the container's page offset
and flew off-screen as soon as the drag started.

Convert the page-coordinate target into the element's containing-block
coordinates by subtracting its offsetParent origin (getOffset + clientLeft/Top
- scrollLeft/Top). Elements with no positioned ancestor (offsetParent null/
body/html) return {0,0}, so Modal and other body-relative draggers are
unchanged. Also fix the playground's raw-BaseDrag demo to position via the
cursor delta from a captured start (offset-parent-agnostic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `DragSort` (the sortable-list dragger) to `@craftcms/garnish`, completing
the Phase-2 drag cluster (BaseDrag → DragMove → Drag → DragDrop → DragSort).

`DragSort extends Drag` and ports the legacy `DragSort.js` faithfully, fully
jQuery-free:

- Live insertion feedback: the draggee block (+ optional `insertion` placeholder)
  is re-inserted into the DOM at the closest landing spot as the cursor moves.
- `_getClosestItem` spatial hit-test (axis-aware x/y/Euclidean distance, outward
  walk with a monotonic-distance early-skip, `canInsertBefore`/`canInsertAfter`
  gating) backed by a per-drag midpoint cache (`_precalculateMidpoints`, only the
  moved item + neighbors recomputed per move, viewport filter for lists > 200).
- `magnetStrength`, `moveTargetItemToFront`, axis options, and the
  `insertionPointChange` / `sortChange` / `dragStart` / `dragStop` events
  (legacy names preserved).
- jQuery removed throughout: `.insertBefore/After` → `ChildNode.before/after`;
  `.offset()` → `getOffset`; `.index()` → manual sibling/`$items` index;
  `$().add()` DOM re-sort → `compareDocumentPosition`; `$.contains` →
  `Node.contains`; `$.data` midpoints → a `Map`.

Also: named + `Garnish`-namespace exports; 32 happy-dom tests (247 total);
a "DragSort — reorderable list" playground demo; design note (doc 11) + impl
notes (doc 12); README and API reference updated (DragSort supported, drag
cluster COMPLETE).

Verified: check:types, test (247), build, playground:build, check:format all
green; `dist/index.js` is jQuery-free (grep count 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `HUD` (Phase 3) — the anchored popover/bubble with smart 4-way
positioning, a tip/arrow, scroll-follow, focus trapping, and UiLayerManager
layer + Escape integration — as a jQuery-free `class HUD extends Base`. This
was the last FieldLayoutDesigner overlay blocker.

- src/hud.ts: faithful port of legacy HUD.js. jQuery removals mapped to the
  modern utils — `.scrollParent()`→getScrollParent, `.offset()`/getOffset,
  `.outerWidth/Height`→getOuterWidth/Height, `:focusable`→getFocusableElements/
  getKeyboardFocusableElements, `$.data`→WeakMap, `Garnish.within`→within, RAF
  re-export, UiLayerManager via the registry. Legacy HUD has no Velocity, so
  show/hide stay as display toggles (no WAAPI conversion). Body content box
  measured via getBoundingClientRect (jQuery `.width()/.height()` parity, and
  mockable in happy-dom).
- src/index.ts: named exports HUD + HUDSettings/HUDOrientation/HUDBodyContents,
  and HUD on the legacy-shaped Garnish namespace.
- tests/hud.test.ts: 39 tests (settings/defaults, construction + param-shift,
  updateBody, show/hide/toggle + events, layer/Esc/shade, submit, focus,
  4-way positioning with mocked rects, updateRecords, destroy).
- playground: section 11 "HUD — anchored popover" (edge-anchored triggers +
  event log + HUD/tip CSS).
- docs: 13-hud-design.md, 14-hud-impl-notes.md; README + 06-api-reference
  mark HUD supported.

Gates: check:types, test (286, +39), build, playground:build, check:format
all green; dist/index.js jQuery refs = 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A draggable Modal is position:fixed, so its offsetParent is null and its
left/top resolve against the viewport, not the page. containingBlockOrigin
treated a null offsetParent as the page origin {0,0}, so the first drag
frame wrote a page-Y target (which includes scrollY) into a viewport-relative
top — jumping the modal down by the page scroll offset.

Split the null-offsetParent branch: a position:fixed element's containing
block is the viewport anchored at the scroll offset, so its origin is
{scrollX, scrollY}. Absolute (no positioned ancestor) and detached elements
still resolve to {0,0}; the positioned-ancestor branch is unchanged. Adds a
regression test asserting a fixed target with scrollY set lands
viewport-relative (no jump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port Garnish's largest remaining UI component (~1,008 LOC) to the modern,
jQuery-free TypeScript/ESM package, following the HUD/Modal overlay patterns.

`class DisclosureMenu extends Base<DisclosureMenuSettings>`:
- Resolves the menu panel from the trigger's aria-controls (or next sibling),
  relocates it to <body>, and toggles via aria-expanded.
- Above/below + left/center/right anchored positioning (scroll/resize-aware).
- Full keyboard nav (arrows + Tab cycle), type-ahead search, focus management.
- UiLayerManager layer + Escape shortcut; outside-mousedown dismissal.
- Instant show; WAAPI fade-out on hide (reduced-motion aware) — Velocity removed.
- Item/group builders (addItem/addItems/addGroup/addHr/removeItem/...) the
  ~19 CP consumer sites rely on; per-item onActivate/callback selection.
- Optional withSearchInput live item filter.

jQuery removals: $(trigger)->getElement, :focusable->focusable matcher,
.scrollParent()->getScrollParent, .velocity('fadeOut')->WAAPI,
$.data('disclosureMenu'/'searchText')->WeakMaps, .find/closest/prevUntil/
nextUntil->native DOM. Craft.getUrl/t/initUiElements routed through an optional
global; $(el).formsubmit() omitted (sets the formsubmit class + data-action).

Exports DisclosureMenu (+ settings/item types) as named exports and on the
legacy-shaped Garnish namespace. Adds 49 happy-dom tests (suite: 336 passing),
a playground section 12 demo, design doc 15, impl notes 16, and README +
06-api-reference entries marking DisclosureMenu supported.

dist/index.js stays jQuery-free (grep -ciE "jquery|\$(" -> 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the single-page Vite playground (12 demo sections in one ~900-line
main.ts) with Storybook, one story file per component.

- Add .storybook config (framework @storybook/html-vite to match Garnish's
  imperative widgets; addons docs/a11y/themes at 10.4.1, mirroring @craftcms/cp).
  preview.css migrates the demo globals from playground/styles.css.
- Add stories/ (top-level, mirroring tests/) with all 12 playground demos:
  modal (basic + draggable/resizable), focus (matcher + trap), compat
  (.extend/this.base), base-drag (standalone boxes + auto-scroll), drag
  (helpers + return/fade), drag-drop, drag-sort, hud, disclosure-menu
  (dropdown + filterable), utilities. Shared stories/_log.ts event-log helper
  (panel + drag-event wiring + layout) and stories/_helpers.ts modal builders.
  Stories import the real source from ../src; args/argTypes drive settings.
- package.json: repoint "dev" to Storybook, add "storybook" + "build:storybook",
  remove "playground:build"; extend format globs to stories + .storybook; add
  Storybook devDeps at ^10.4.1.
- tsconfig.json includes stories + .storybook; remove playground/ and the
  package-root vite.config.ts (it only served the playground).
- README + new docs/17-storybook-notes.md document the Storybook workflow and
  how future component ports add a stories/<name>.stories.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hand-rolled in-canvas event-log panel with Storybook's built-in
Actions panel (`storybook/actions`, part of core in 10.x — no extra addon).

- stories/_log.ts: createEventLog() now wraps `action()`, memoizing one named
  action per tag so events group by tag in the Actions panel. Drop the panel/
  clear/initialMessage API; storyLayout(main) just tags the demo container.
- Update all story call sites: createEventLog(), storyLayout(main), drop the
  isError third arg.
- preview.css: remove the .pg-log* panel styles; .pg-story is now a simple
  single-column demo container, not a two-column flex with a side panel.
- Update docs/17-storybook-notes.md and README to describe the Actions panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the ported FieldLayoutDesigner to native DOM + WeakMaps, keeping
thin jQuery only where it hands off to Craft's still-jQuery widgets
(Craft.Grid/Listbox/SlidePicker/SortableCheckboxSelect/Slideout, the
.disclosureMenu() plugin, and form .serialize()).

- All of FLD's own jQuery converted to native: createElement/querySelector,
  classList, dataset, textContent/innerHTML/value, native tree ops,
  getOffset/getOuterHeight, Web Animations API for fades.
- .data() replaced with module-level WeakMaps in support.ts
  (fld-tab/fld-element/hud/cvd + drag midpoints) and dataset reads.
- Garnish Drag $items/$draggee/helpers used as native arrays (wrappers
  removed); $insertion/$caboose are native too.
- README updated to describe the native conversion and remaining seams.

Gates: FLD typecheck clean, eslint clean. Full vite build is env-blocked
in this worktree (no vendor/) and not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brianjhanson brianjhanson changed the title Feature/modernize garnish [6.x] Modernize garnish Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant